[id].vue 43 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112
  1. <template>
  2. <div class="admin--page-content">
  3. <div class="admin--form">
  4. <form @submit.prevent="handleSubmit">
  5. <!-- ============================
  6. 챌린지 기본 정보
  7. ============================ -->
  8. <table class="admin--form--table">
  9. <colgroup>
  10. <col style="width: 140px;">
  11. <col>
  12. </colgroup>
  13. <tbody>
  14. <tr>
  15. <th><div>챌린지명 <span class="admin--required">*</span></div></th>
  16. <td>
  17. <div class="input--wrap">
  18. <input v-model="formData.name" type="text" class="w--full admin--form-input" placeholder="예: 동해안 왕대구 선상낚시대회" required />
  19. </div>
  20. </td>
  21. </tr>
  22. <tr>
  23. <th><div>참가비 <span class="admin--required">*</span></div></th>
  24. <td>
  25. <div class="input--wrap">
  26. <input
  27. v-model="formData.fee"
  28. type="number"
  29. min="0"
  30. class="admin--form-input w--200"
  31. :placeholder="isFree ? '0 (무료)' : '예: 10000'"
  32. :disabled="isFree"
  33. required
  34. />
  35. <span>원</span>
  36. <label class="admin--radio-label ml--16">
  37. <input type="checkbox" v-model="isFree" @change="onFreeChange" /> 무료
  38. </label>
  39. </div>
  40. </td>
  41. </tr>
  42. <tr>
  43. <th><div>기간 <span class="admin--required">*</span></div></th>
  44. <td>
  45. <div class="input--wrap">
  46. <DatePicker v-model="startDate" placeholder="📅 YYYY-MM-DD" />
  47. <span class="admin--date-separator">-</span>
  48. <DatePicker v-model="endDate" placeholder="📅 YYYY-MM-DD" />
  49. </div>
  50. </td>
  51. </tr>
  52. <tr>
  53. <th><div>최대 참가자 <span class="admin--required">*</span></div></th>
  54. <td>
  55. <div class="input--wrap">
  56. <input v-model="formData.max_participants" type="number" min="100" max="999999" class="admin--form-input w--120" placeholder="100 ~ 999999" required />
  57. <span>명</span>
  58. </div>
  59. </td>
  60. </tr>
  61. <tr>
  62. <th><div>타이틀 이미지</div></th>
  63. <td>
  64. <div class="input--wrap">
  65. <input
  66. ref="imageInput"
  67. type="file"
  68. accept="image/*"
  69. class="admin--form-file-hidden"
  70. @change="onImageChange"
  71. />
  72. <button type="button" class="admin--btn-small admin--btn-blue" @click="triggerImageInput">
  73. {{ existingImagePath || image ? '이미지 변경' : '이미지 선택' }}
  74. </button>
  75. <span v-if="image" class="ml--16">{{ image.file.name }} (신규)</span>
  76. <span v-else-if="existingImagePath" class="ml--16">{{ existingImageName || '기존 이미지' }}</span>
  77. <button
  78. v-if="existingImagePath && !image"
  79. type="button"
  80. class="admin--btn-small admin--btn-red ml--8"
  81. @click="removeExistingImage"
  82. >
  83. 이미지 제거
  84. </button>
  85. </div>
  86. <p class="mt--10">권장 1200x800, JPG/PNG/GIF/WebP, 5MB 이하 (선택 사항)</p>
  87. <!-- 신규 이미지 미리보기 (있으면 우선) -->
  88. <div v-if="image" class="onboard--photo-grid mt--10">
  89. <div class="onboard--photo-item">
  90. <img :src="image.preview" alt="신규 미리보기" />
  91. <button type="button" class="onboard--photo-remove" @click="removeImage"></button>
  92. </div>
  93. </div>
  94. <!-- 신규 없으면 기존 이미지 -->
  95. <div v-else-if="existingImagePath" class="onboard--photo-grid mt--10">
  96. <div class="onboard--photo-item">
  97. <img :src="getImageUrl(existingImagePath)" alt="기존 이미지" />
  98. </div>
  99. </div>
  100. </td>
  101. </tr>
  102. <tr>
  103. <th><div>상태 <span class="admin--required">*</span></div></th>
  104. <td>
  105. <div class="input--wrap">
  106. <label class="admin--radio-label">
  107. <input type="radio" v-model="formData.status_YN" value="Y" /> 사용중
  108. </label>
  109. <label class="admin--radio-label ml--16">
  110. <input type="radio" v-model="formData.status_YN" value="N" /> 미사용
  111. </label>
  112. </div>
  113. </td>
  114. </tr>
  115. <tr>
  116. <th><div>상세내용</div></th>
  117. <td>
  118. <ClientOnly>
  119. <SunEditor
  120. v-model="formData.description"
  121. height="400px"
  122. placeholder="챌린지 상세 설명을 입력하세요. (참가 방법ㆍ규칙ㆍ유의사항 등)"
  123. />
  124. </ClientOnly>
  125. </td>
  126. </tr>
  127. </tbody>
  128. </table>
  129. <!-- ============================
  130. 라운드 설정
  131. ============================ -->
  132. <h3 class="admin--table--middle--title">라운드 설정</h3>
  133. <p class="admin--table--middle--desc">
  134. 라운드별로 장소 범위(전체/개별)를 선택하고, 장소마다 아이템을 배정합니다. 최대 5라운드까지 등록할 수 있습니다.
  135. </p>
  136. <div
  137. v-for="(round, rIdx) in rounds"
  138. :key="round._key"
  139. class="admin--round--box--wrap"
  140. :class="{ 'mt--16': rIdx > 0 }"
  141. >
  142. <div class="admin--round--title">
  143. 라운드 {{ round.round_no }}
  144. <span v-if="round.place_mode === 'all'">전체 장소ㆍ아이템 {{ round.items.length }}</span>
  145. <span v-else>개별 장소 {{ round.places.length }}</span>
  146. <!-- 최소 2라운드 이상. 3라운드부터 라운드 삭제 버튼 노출 -->
  147. <button
  148. v-if="rounds.length > 2"
  149. type="button"
  150. class="round--remove--btn"
  151. @click="removeRound(rIdx)"
  152. >
  153. 라운드 삭제
  154. </button>
  155. </div>
  156. <div class="admin--round--box">
  157. <div class="input--wrap mt--16">
  158. <label class="admin--round--radio">
  159. <input
  160. type="radio"
  161. :name="'place_mode_' + round._key"
  162. value="all"
  163. :checked="round.place_mode === 'all'"
  164. @change="changePlaceMode(round, 'all')"
  165. />
  166. 전체 장소에 동일 적용
  167. </label>
  168. <label class="admin--round--radio">
  169. <input
  170. type="radio"
  171. :name="'place_mode_' + round._key"
  172. value="specific"
  173. :checked="round.place_mode === 'specific'"
  174. @change="changePlaceMode(round, 'specific')"
  175. />
  176. 장소별 개별 설정
  177. </label>
  178. </div>
  179. <div class="qual--wrap">
  180. <p class="mt--16 mb--4">진출자 {{ rIdx === 0 ? '인원' : '확률' }}</p>
  181. <div class="input--wrap">
  182. <input
  183. v-model="round.qualified"
  184. type="number"
  185. min="1"
  186. class="admin--form-input w--120"
  187. :placeholder="rIdx === 0 ? '예: 30' : '예: 50'"
  188. required
  189. />
  190. <!-- 1라운드 단위 : 명, 그 외 라운드 단위 : % -->
  191. <span>{{ rIdx === 0 ? '명' : '%' }}</span>
  192. </div>
  193. </div>
  194. <!-- 전체 적용 모드 — 라운드 단위 아이템 -->
  195. <div v-if="round.place_mode === 'all'" class="item--select--wrap">
  196. <div class="item--select--btn--wrap mt--16 mb--4">
  197. <p>배정 아이템ㆍ수량 {{ round.items.length }}</p>
  198. <button type="button" @click="openItemModal(round)">+ 아이템 선택</button>
  199. </div>
  200. <div class="item--selected--wrap">
  201. <div v-for="(it, iIdx) in round.items" :key="it.item_id" class="item--selected">
  202. {{ it.name }}<button type="button" @click="round.items.splice(iIdx, 1)">✕</button>
  203. </div>
  204. </div>
  205. </div>
  206. <!-- 장소별 개별 설정 모드 -->
  207. <template v-else>
  208. <div
  209. v-for="(place, pIdx) in round.places"
  210. :key="place._key"
  211. class="round--place--wrap"
  212. >
  213. <div class="admin--round--title">
  214. 장소 {{ pIdx + 1 }}
  215. <button
  216. type="button"
  217. class="place--remove--btn"
  218. @click="removePlace(round, pIdx)"
  219. >✕</button>
  220. </div>
  221. <div class="place--select--wrap">
  222. <p class="mb--4">장소 정의</p>
  223. <!-- 분야/지역/제휴 셀렉트 — 항상 노출 -->
  224. <div class="input--wrap">
  225. <select v-model="place.field_id" class="admin--form-select">
  226. <option value="">전체 분야</option>
  227. <option v-for="f in fieldOptions" :key="f.id" :value="f.id">{{ f.name }}</option>
  228. </select>
  229. <select v-model="place.area_id" class="admin--form-select">
  230. <option value="">전체 지역</option>
  231. <option v-for="a in areaOptions" :key="a.id" :value="a.id">{{ a.name }}</option>
  232. </select>
  233. <select v-model="place.partnership_YN" class="admin--form-select">
  234. <option value="">제휴 여부</option>
  235. <option value="Y">제휴</option>
  236. <option value="N">비제휴</option>
  237. </select>
  238. <!-- 장소 미선택 시: "장소 선택" 버튼 + 드롭다운 -->
  239. <div v-if="place.onboards.length === 0" class="place--select--btn--wrap">
  240. <button
  241. type="button"
  242. class="admin--form-select"
  243. @click.stop="openDropdown(place)"
  244. >
  245. 장소 선택
  246. </button>
  247. <div
  248. v-if="place.dropdownOpen"
  249. class="all--place--wrap"
  250. @click.stop
  251. >
  252. <div class="place--top">
  253. <div class="search--wrap">
  254. <input v-model="place.searchKeyword" type="text" placeholder="선상ㆍ낚시터명 검색">
  255. </div>
  256. <div class="check--wrap">
  257. <label>
  258. <input
  259. type="checkbox"
  260. :checked="isAllFilteredSelected(place)"
  261. @change="toggleAll(place)"
  262. >
  263. 전체
  264. <span>조건의 모든 장소에 적용</span>
  265. </label>
  266. </div>
  267. <div class="all--place">
  268. <p>등록된 선상ㆍ낚시터</p>
  269. <ul class="all--place--list mt--6">
  270. <template v-for="group in groupedFilteredPlaces(place)" :key="group.area">
  271. <p class="group--header">
  272. {{ group.area }}
  273. <button
  274. type="button"
  275. @click="toggleAllInGroup(place, group.items)"
  276. >
  277. {{ isAllInGroupSelected(place, group.items) ? '그룹 해제' : '그룹 전체 선택' }}
  278. </button>
  279. </p>
  280. <li v-for="p in group.items" :key="placeKey(p)">
  281. <label>
  282. <input
  283. type="checkbox"
  284. :checked="place.tempSelected.includes(placeKey(p))"
  285. @change="togglePlaceInTemp(place, placeKey(p))"
  286. >
  287. <span>{{ p._placeType === 'onboard' ? '🚤' : '🎣' }} {{ p.name }}</span>
  288. <span :class="p.partnership_YN === 'Y' ? 'on' : 'off'">
  289. {{ p.partnership_YN === 'Y' ? '제휴' : '비제휴' }}
  290. </span>
  291. <p>{{ p.field_name || '-' }}</p>
  292. </label>
  293. </li>
  294. </template>
  295. <li v-if="filteredPlaces(place).length === 0" class="empty">
  296. 조건에 맞는 장소가 없습니다.
  297. </li>
  298. </ul>
  299. </div>
  300. </div>
  301. <div class="place--bot">
  302. <p>{{ place.tempSelected.length }}개 선택</p>
  303. <button type="button" @click="applyDropdown(place)">적용</button>
  304. </div>
  305. </div>
  306. </div>
  307. </div>
  308. <!-- 장소 선택 후 — 별도 영역에 "선상ㆍ낚시터 복수 선택" + 칩 -->
  309. <template v-if="place.onboards.length > 0">
  310. <p class="mt--16 mb--4">선상ㆍ낚시터 복수 선택</p>
  311. <div class="place--select--btn--wrap">
  312. <div
  313. class="admin--form-select"
  314. @click.stop="openDropdown(place)"
  315. >
  316. <div
  317. v-for="chip in displayChips(place).slice(0, 2)"
  318. :key="chip.key"
  319. class="place--selected"
  320. :class="{ 'is-group': chip.type === 'group' }"
  321. >
  322. {{ chip.icon }} {{ chip.label }}
  323. <button
  324. type="button"
  325. @click.stop="removeChipFromPlace(place, chip)"
  326. >✕</button>
  327. </div>
  328. <div v-if="displayChips(place).length > 2" class="place--selected">
  329. + {{ displayChips(place).length - 2 }}
  330. </div>
  331. </div>
  332. <div
  333. v-if="place.dropdownOpen"
  334. class="all--place--wrap"
  335. @click.stop
  336. >
  337. <div class="place--top">
  338. <div class="search--wrap">
  339. <input v-model="place.searchKeyword" type="text" placeholder="선상ㆍ낚시터명 검색">
  340. </div>
  341. <div class="check--wrap">
  342. <label>
  343. <input
  344. type="checkbox"
  345. :checked="isAllFilteredSelected(place)"
  346. @change="toggleAll(place)"
  347. >
  348. 전체
  349. <span>조건의 모든 장소에 적용</span>
  350. </label>
  351. </div>
  352. <div class="all--place">
  353. <p>등록된 선상ㆍ낚시터</p>
  354. <ul class="all--place--list mt--6">
  355. <template v-for="group in groupedFilteredPlaces(place)" :key="group.area">
  356. <p class="group--header">
  357. {{ group.area }}
  358. <button
  359. type="button"
  360. @click="toggleAllInGroup(place, group.items)"
  361. >
  362. {{ isAllInGroupSelected(place, group.items) ? '그룹 해제' : '그룹 전체 선택' }}
  363. </button>
  364. </p>
  365. <li v-for="p in group.items" :key="placeKey(p)">
  366. <label>
  367. <input
  368. type="checkbox"
  369. :checked="place.tempSelected.includes(placeKey(p))"
  370. @change="togglePlaceInTemp(place, placeKey(p))"
  371. >
  372. <span>{{ p._placeType === 'onboard' ? '🚤' : '🎣' }} {{ p.name }}</span>
  373. <span :class="p.partnership_YN === 'Y' ? 'on' : 'off'">
  374. {{ p.partnership_YN === 'Y' ? '제휴' : '비제휴' }}
  375. </span>
  376. <p>{{ p.field_name || '-' }}</p>
  377. </label>
  378. </li>
  379. </template>
  380. <li v-if="filteredPlaces(place).length === 0" class="empty">
  381. 조건에 맞는 장소가 없습니다.
  382. </li>
  383. </ul>
  384. </div>
  385. </div>
  386. <div class="place--bot">
  387. <p>{{ place.tempSelected.length }}개 선택</p>
  388. <button type="button" @click="applyDropdown(place)">적용</button>
  389. </div>
  390. </div>
  391. </div>
  392. </template>
  393. </div>
  394. <!-- 장소별 아이템 -->
  395. <div class="item--select--wrap">
  396. <div class="item--select--btn--wrap mb--4 mt--16">
  397. <p>배정 아이템ㆍ수량 {{ place.items.length }}</p>
  398. <button type="button" @click="openItemModal(place)">+ 아이템 선택</button>
  399. </div>
  400. <div class="item--selected--wrap">
  401. <div v-for="(it, iIdx) in place.items" :key="it.item_id" class="item--selected">
  402. {{ it.name }}<button type="button" @click="place.items.splice(iIdx, 1)">✕</button>
  403. </div>
  404. </div>
  405. </div>
  406. </div>
  407. <button type="button" class="place--add--btn" @click="addPlace(round)">
  408. + 장소 추가
  409. </button>
  410. </template>
  411. </div>
  412. </div>
  413. <button
  414. v-if="rounds.length < 5"
  415. type="button"
  416. class="round--add--btn"
  417. @click="addRound"
  418. >
  419. + 라운드 추가 (최대 5라운드)
  420. </button>
  421. <!-- 버튼 영역 -->
  422. <div class="admin--form-actions">
  423. <button type="button" class="admin--btn" @click="goToDetail">
  424. ← 취소
  425. </button>
  426. <button type="submit" class="admin--btn admin--btn-red ml--auto" :disabled="isSaving || isLoading">
  427. {{ isSaving ? "저장 중..." : "수정 저장" }}
  428. </button>
  429. </div>
  430. <!-- 성공/에러 메시지 -->
  431. <div v-if="successMessage" class="admin--alert admin--alert-success">
  432. {{ successMessage }}
  433. </div>
  434. <div v-if="errorMessage" class="admin--alert admin--alert-error">
  435. {{ errorMessage }}
  436. </div>
  437. </form>
  438. </div>
  439. <!-- ============================
  440. 아이템 선택 모달
  441. ============================ -->
  442. <ClientOnly>
  443. <Teleport to="body">
  444. <div
  445. v-if="itemModal.isOpen"
  446. class="admin--modal-overlay admin--alert-overlay"
  447. @click.self="closeItemModal"
  448. >
  449. <div class="admin--modal admin--form-modal admin--item-modal" @click.stop>
  450. <div class="admin--modal-header">
  451. <h4>아이템 선택</h4>
  452. <button type="button" class="admin--modal-close" @click="closeItemModal">✕</button>
  453. </div>
  454. <div class="admin--modal-body">
  455. <div class="admin--item-modal__search mb--16">
  456. <input
  457. v-model="itemModal.searchKeyword"
  458. type="text"
  459. class="admin--form-input w--full"
  460. placeholder="🔍 아이템명 검색"
  461. />
  462. </div>
  463. <ul v-if="filteredItems().length > 0" class="admin--item-modal__grid">
  464. <li
  465. v-for="it in filteredItems()"
  466. :key="it.id"
  467. class="admin--item-modal__card"
  468. :class="{ 'is-selected': itemModal.tempSelected.includes(it.id) }"
  469. >
  470. <label>
  471. <input
  472. type="checkbox"
  473. :checked="itemModal.tempSelected.includes(it.id)"
  474. @change="toggleItemInModal(it.id)"
  475. />
  476. <div class="admin--item-modal__thumb">
  477. <img
  478. v-if="it.file_path"
  479. :src="getImageUrl(it.file_path)"
  480. :alt="it.name"
  481. />
  482. <div v-else class="admin--item-modal__no-img">🎁</div>
  483. </div>
  484. <div class="admin--item-modal__name">{{ it.name }}</div>
  485. <div class="admin--item-modal__meta">
  486. <span v-if="it.type" class="admin--item-modal__type">{{ it.type == 'B' ? '뱃지' : it.type == 'P' ? '포인트' : '진출권' }}</span>
  487. <span v-if="it.point !== null && it.point !== undefined" class="admin--item-modal__point">{{ it.point }}P</span>
  488. </div>
  489. </label>
  490. </li>
  491. </ul>
  492. <div v-else class="admin--item-modal__empty">
  493. {{ itemModal.searchKeyword ? '검색 결과가 없습니다.' : '등록된 아이템이 없습니다.' }}
  494. </div>
  495. </div>
  496. <div class="admin--modal-footer">
  497. <span class="admin--item-modal__count mr--auto">{{ itemModal.tempSelected.length }}개 선택</span>
  498. <button type="button" class="admin--btn" @click="closeItemModal">취소</button>
  499. <button type="button" class="admin--btn admin--btn-primary-fill ml--8" @click="applyItemModal">적용</button>
  500. </div>
  501. </div>
  502. </div>
  503. </Teleport>
  504. </ClientOnly>
  505. </div>
  506. </template>
  507. <script setup>
  508. import { ref, onMounted, onBeforeUnmount } from "vue";
  509. import { useRoute, useRouter } from "vue-router";
  510. import DatePicker from "~/components/admin/DatePicker.vue";
  511. import SunEditor from "~/components/admin/SunEditor.vue";
  512. definePageMeta({
  513. layout: "admin",
  514. middleware: ["auth"],
  515. });
  516. const route = useRoute();
  517. const router = useRouter();
  518. const { get, put, del, upload } = useApi();
  519. const { getImageUrl } = useImage();
  520. const challengeId = Number(route.params.id);
  521. const isLoading = ref(false);
  522. const isSaving = ref(false);
  523. const successMessage = ref("");
  524. const errorMessage = ref("");
  525. // ============================
  526. // 옵션 데이터
  527. // ============================
  528. const fieldOptions = ref([]);
  529. const areaOptions = ref([]);
  530. const placesAll = ref([]); // 검색용 전체 장소 (선상 + 낚시터, _placeType 필드로 구분)
  531. const itemsAll = ref([]); // 아이템 모달용 전체 아이템
  532. // ============================
  533. // 챌린지 기본 정보
  534. // ============================
  535. const formData = ref({
  536. name: "",
  537. fee: "",
  538. max_participants: "",
  539. status_YN: "Y",
  540. description: "",
  541. });
  542. const startDate = ref("");
  543. const endDate = ref("");
  544. const isFree = ref(false);
  545. // 무료 체크박스 토글
  546. const onFreeChange = () => {
  547. if (isFree.value) {
  548. formData.value.fee = "0";
  549. } else {
  550. formData.value.fee = "";
  551. }
  552. };
  553. // ============================
  554. // 이미지 업로드 (기존 + 신규)
  555. // ============================
  556. const imageInput = ref(null);
  557. const image = ref(null); // 신규 업로드 (file + preview)
  558. const existingImagePath = ref(null); // 기존 file_path
  559. const existingImageName = ref(null); // 기존 file_name
  560. const MAX_IMAGE_SIZE = 5 * 1024 * 1024;
  561. const triggerImageInput = () => imageInput.value?.click();
  562. const onImageChange = (e) => {
  563. const file = (e.target.files || [])[0];
  564. e.target.value = "";
  565. if (!file) return;
  566. if (!file.type.startsWith("image/")) {
  567. errorMessage.value = "이미지 파일만 업로드할 수 있습니다.";
  568. return;
  569. }
  570. if (file.size > MAX_IMAGE_SIZE) {
  571. errorMessage.value = "이미지가 5MB를 초과합니다.";
  572. return;
  573. }
  574. if (image.value) URL.revokeObjectURL(image.value.preview);
  575. image.value = { file, preview: URL.createObjectURL(file) };
  576. };
  577. const removeImage = () => {
  578. if (image.value) {
  579. URL.revokeObjectURL(image.value.preview);
  580. image.value = null;
  581. }
  582. };
  583. // ============================
  584. // 라운드/장소 동적 배열
  585. // ============================
  586. let _keySeq = 0;
  587. const nextKey = () => ++_keySeq;
  588. function createPlace() {
  589. return {
  590. _key: nextKey(),
  591. field_id: "",
  592. area_id: "",
  593. partnership_YN: "",
  594. onboards: [], // 적용된 장소 키 배열 (예: 'onboard-1', 'fishing-3')
  595. items: [], // [{ item_id, name, qty }] — Phase 2
  596. // UI 상태
  597. dropdownOpen: false,
  598. searchKeyword: "",
  599. tempSelected: [], // 드롭다운 내 임시 체크 (장소 키 배열)
  600. };
  601. }
  602. function createRound(no) {
  603. return {
  604. _key: nextKey(),
  605. round_no: no,
  606. place_mode: "all",
  607. qualified: "",
  608. items: [], // [{ item_id, name, qty }] — Phase 2
  609. places: [],
  610. };
  611. }
  612. const rounds = ref([createRound(1), createRound(2)]);
  613. function renumberRounds() {
  614. rounds.value.forEach((r, i) => { r.round_no = i + 1; });
  615. }
  616. function addRound() {
  617. if (rounds.value.length >= 5) return;
  618. rounds.value.push(createRound(rounds.value.length + 1));
  619. }
  620. function removeRound(idx) {
  621. if (rounds.value.length <= 2) return;
  622. rounds.value.splice(idx, 1);
  623. renumberRounds();
  624. }
  625. function changePlaceMode(round, mode) {
  626. round.place_mode = mode;
  627. // specific 으로 전환했는데 장소가 없으면 자동으로 장소 1 생성
  628. if (mode === "specific" && round.places.length === 0) {
  629. round.places.push(createPlace());
  630. }
  631. }
  632. function addPlace(round) {
  633. round.places.push(createPlace());
  634. }
  635. function removePlace(round, idx) {
  636. round.places.splice(idx, 1);
  637. // specific 모드인데 장소가 0개면 자동으로 1개 다시 추가 (또는 all 모드로 되돌리기 — 여기선 하나 자동 추가)
  638. if (round.places.length === 0) {
  639. round.places.push(createPlace());
  640. }
  641. }
  642. // ============================
  643. // 장소(선상+낚시터) 검색 드롭다운
  644. // ============================
  645. // 장소 키 헬퍼: 'onboard-1', 'fishing-2' 형태로 고유 식별
  646. const placeKey = (p) => `${p._placeType}-${p.id}`;
  647. const placeByKey = (k) => placesAll.value.find((p) => placeKey(p) === k);
  648. const placeNameByKey = (k) => placeByKey(k)?.name || "?";
  649. const placeTypeByKey = (k) => (k && k.startsWith("fishing-")) ? "fishing" : "onboard";
  650. function closeAllDropdowns() {
  651. rounds.value.forEach((r) =>
  652. r.places.forEach((p) => { p.dropdownOpen = false; })
  653. );
  654. }
  655. function openDropdown(place) {
  656. closeAllDropdowns();
  657. place.tempSelected = [...place.onboards];
  658. place.dropdownOpen = true;
  659. }
  660. function filteredPlaces(place) {
  661. return placesAll.value.filter((p) => {
  662. if (place.field_id && String(p.field_id) !== String(place.field_id)) return false;
  663. if (place.area_id && String(p.area_id) !== String(place.area_id)) return false;
  664. if (place.partnership_YN && p.partnership_YN !== place.partnership_YN) return false;
  665. if (place.searchKeyword) {
  666. const kw = place.searchKeyword.toLowerCase();
  667. if (!String(p.name || "").toLowerCase().includes(kw)) return false;
  668. }
  669. return true;
  670. });
  671. }
  672. function togglePlaceInTemp(place, key) {
  673. const idx = place.tempSelected.indexOf(key);
  674. if (idx === -1) place.tempSelected.push(key);
  675. else place.tempSelected.splice(idx, 1);
  676. }
  677. function isAllFilteredSelected(place) {
  678. const filtered = filteredPlaces(place);
  679. if (filtered.length === 0) return false;
  680. return filtered.every((p) => place.tempSelected.includes(placeKey(p)));
  681. }
  682. function toggleAll(place) {
  683. const filtered = filteredPlaces(place);
  684. const filteredKeys = filtered.map(placeKey);
  685. if (isAllFilteredSelected(place)) {
  686. const set = new Set(filteredKeys);
  687. place.tempSelected = place.tempSelected.filter((k) => !set.has(k));
  688. } else {
  689. const merged = new Set([...place.tempSelected, ...filteredKeys]);
  690. place.tempSelected = [...merged];
  691. }
  692. }
  693. // 지역별 그룹화: [{area, items: [...]}, ...]
  694. function groupedFilteredPlaces(place) {
  695. const filtered = filteredPlaces(place);
  696. const map = new Map();
  697. filtered.forEach((p) => {
  698. const area = p.area_name || "미분류";
  699. if (!map.has(area)) map.set(area, []);
  700. map.get(area).push(p);
  701. });
  702. return Array.from(map.entries()).map(([area, items]) => ({ area, items }));
  703. }
  704. function isAllInGroupSelected(place, items) {
  705. if (!items || items.length === 0) return false;
  706. return items.every((p) => place.tempSelected.includes(placeKey(p)));
  707. }
  708. function toggleAllInGroup(place, items) {
  709. const keys = items.map(placeKey);
  710. if (isAllInGroupSelected(place, items)) {
  711. const set = new Set(keys);
  712. place.tempSelected = place.tempSelected.filter((k) => !set.has(k));
  713. } else {
  714. const merged = new Set([...place.tempSelected, ...keys]);
  715. place.tempSelected = [...merged];
  716. }
  717. }
  718. function applyDropdown(place) {
  719. place.onboards = [...place.tempSelected];
  720. place.dropdownOpen = false;
  721. }
  722. function removePlaceChip(place, key) {
  723. place.onboards = place.onboards.filter((k) => k !== key);
  724. }
  725. // 칩 표시용 — 같은 지역의 모든 장소가 선택됐으면 "○○ 전체" 그룹 칩으로 묶음
  726. function displayChips(place) {
  727. const selectedKeys = new Set(place.onboards);
  728. const groupedAll = new Map();
  729. placesAll.value.forEach((p) => {
  730. const area = p.area_name || "미분류";
  731. if (!groupedAll.has(area)) groupedAll.set(area, []);
  732. groupedAll.get(area).push(p);
  733. });
  734. const result = [];
  735. const processedKeys = new Set();
  736. for (const [area, places] of groupedAll.entries()) {
  737. if (places.length < 2) continue;
  738. const groupKeys = places.map(placeKey);
  739. const allSelected = groupKeys.every((k) => selectedKeys.has(k));
  740. if (allSelected) {
  741. result.push({
  742. key: `group:${area}`,
  743. type: "group",
  744. label: `${area} 전체`,
  745. icon: "📍",
  746. keys: groupKeys,
  747. });
  748. groupKeys.forEach((k) => processedKeys.add(k));
  749. }
  750. }
  751. for (const key of place.onboards) {
  752. if (processedKeys.has(key)) continue;
  753. result.push({
  754. key,
  755. type: "single",
  756. label: placeNameByKey(key),
  757. icon: placeTypeByKey(key) === "onboard" ? "🚤" : "🎣",
  758. keys: [key],
  759. });
  760. }
  761. return result;
  762. }
  763. function removeChipFromPlace(place, chip) {
  764. const keysToRemove = new Set(chip.keys);
  765. place.onboards = place.onboards.filter((k) => !keysToRemove.has(k));
  766. }
  767. // 외부 클릭 시 모든 드롭다운 닫기
  768. function handleDocumentClick() {
  769. closeAllDropdowns();
  770. }
  771. // ============================
  772. // 아이템 선택 모달
  773. // ============================
  774. const itemModal = ref({
  775. isOpen: false,
  776. target: null, // round 또는 place 객체 (둘 다 .items 배열 가짐)
  777. tempSelected: [], // 임시 선택된 item id 배열
  778. searchKeyword: "",
  779. });
  780. function openItemModal(target) {
  781. itemModal.value.target = target;
  782. itemModal.value.tempSelected = target.items.map((i) => i.item_id);
  783. itemModal.value.searchKeyword = "";
  784. itemModal.value.isOpen = true;
  785. }
  786. function closeItemModal() {
  787. itemModal.value.isOpen = false;
  788. itemModal.value.target = null;
  789. itemModal.value.tempSelected = [];
  790. itemModal.value.searchKeyword = "";
  791. }
  792. function toggleItemInModal(itemId) {
  793. const idx = itemModal.value.tempSelected.indexOf(itemId);
  794. if (idx === -1) itemModal.value.tempSelected.push(itemId);
  795. else itemModal.value.tempSelected.splice(idx, 1);
  796. }
  797. function filteredItems() {
  798. if (!itemModal.value.searchKeyword) return itemsAll.value;
  799. const kw = itemModal.value.searchKeyword.toLowerCase();
  800. return itemsAll.value.filter((i) =>
  801. String(i.name || "").toLowerCase().includes(kw)
  802. );
  803. }
  804. function applyItemModal() {
  805. const target = itemModal.value.target;
  806. if (!target) return;
  807. target.items = itemModal.value.tempSelected.map((id) => {
  808. const it = itemsAll.value.find((x) => x.id === id);
  809. return {
  810. item_id: id,
  811. name: it?.name || "?",
  812. type: it?.type || "",
  813. point: it?.point ?? null,
  814. };
  815. });
  816. closeItemModal();
  817. }
  818. // ============================
  819. // 데이터 로드
  820. // ============================
  821. async function loadOptions() {
  822. try {
  823. const [fieldRes, areaRes, onboardRes, fishingRes, itemRes] = await Promise.all([
  824. get("/field/list", { params: { per_page: 1000 } }),
  825. get("/area/list", { params: { per_page: 1000 } }),
  826. get("/onboard/list", { params: { per_page: 1000 } }),
  827. get("/fishing/list", { params: { per_page: 1000 } }),
  828. get("/item/list", { params: { per_page: 1000, status: "Y" } }),
  829. ]);
  830. if (fieldRes.data?.success) fieldOptions.value = (fieldRes.data.data.items || []).reverse();
  831. if (areaRes.data?.success) areaOptions.value = (areaRes.data.data.items || []).reverse();
  832. // 선상 + 낚시터 통합 (_placeType으로 구분)
  833. const onboards = (onboardRes.data?.success ? (onboardRes.data.data.items || []) : [])
  834. .map((o) => ({ ...o, _placeType: "onboard" }));
  835. const fishings = (fishingRes.data?.success ? (fishingRes.data.data.items || []) : [])
  836. .map((f) => ({ ...f, _placeType: "fishing" }));
  837. placesAll.value = [...onboards, ...fishings];
  838. if (itemRes.data?.success) itemsAll.value = itemRes.data.data.items || [];
  839. } catch (e) {
  840. console.error("Load options error:", e);
  841. }
  842. }
  843. // ============================
  844. // 챌린지 로드 + 폼 채우기
  845. // ============================
  846. async function loadChallenge() {
  847. isLoading.value = true;
  848. try {
  849. const { data, error } = await get(`/challenge/${challengeId}`);
  850. if (error || !data?.success) {
  851. errorMessage.value = error?.message || data?.message || "챌린지를 불러올 수 없습니다.";
  852. return;
  853. }
  854. const c = data.data;
  855. // 기본 정보
  856. const feeNum = Number(c.fee);
  857. isFree.value = !isNaN(feeNum) && feeNum === 0;
  858. formData.value = {
  859. name: c.name || "",
  860. fee: String(c.fee ?? ""),
  861. max_participants: c.max_participants || "",
  862. status_YN: c.status_YN || "Y",
  863. description: c.description || "",
  864. };
  865. startDate.value = c.start_date ? String(c.start_date).substring(0, 10) : "";
  866. endDate.value = c.end_date ? String(c.end_date).substring(0, 10) : "";
  867. // 기존 이미지
  868. if (c.file_path) {
  869. existingImagePath.value = c.file_path;
  870. existingImageName.value = c.file_name;
  871. }
  872. // 라운드 변환 (응답 구조 → 폼 구조)
  873. rounds.value = (c.rounds || []).map((r) => {
  874. const round = createRound(r.round_no);
  875. round.place_mode = r.place_mode;
  876. round.qualified = String(r.qualified || "");
  877. if (r.place_mode === "all") {
  878. round.items = (r.items || []).map((it) => ({
  879. item_id: it.item_id,
  880. name: it.name,
  881. type: it.type,
  882. point: it.point,
  883. }));
  884. round.places = [];
  885. } else {
  886. round.items = [];
  887. round.places = (r.places || []).map((p) => {
  888. const place = createPlace();
  889. // field_id, area_id, partnership_YN은 응답에 없음 (필터 조건은 저장 안 됨)
  890. // 사용자가 수정 시 다시 필터 가능
  891. place.onboards = (p.onboards || []).map((o) => `${o.place_type}-${o.place_id}`);
  892. place.items = (p.items || []).map((it) => ({
  893. item_id: it.item_id,
  894. name: it.name,
  895. type: it.type,
  896. point: it.point,
  897. }));
  898. return place;
  899. });
  900. }
  901. return round;
  902. });
  903. // 최소 2라운드 보장 (이상 케이스 대비)
  904. while (rounds.value.length < 2) {
  905. rounds.value.push(createRound(rounds.value.length + 1));
  906. }
  907. } catch (e) {
  908. console.error("[ChallengeEdit] 로드 실패:", e);
  909. errorMessage.value = "서버 오류가 발생했습니다.";
  910. } finally {
  911. isLoading.value = false;
  912. }
  913. }
  914. // 기존 이미지 제거 (즉시 백엔드 호출)
  915. async function removeExistingImage() {
  916. if (!confirm("기존 이미지를 제거하시겠습니까?")) return;
  917. try {
  918. const { data, error } = await del(`/challenge/${challengeId}/image`);
  919. if (error || !data?.success) {
  920. errorMessage.value = error?.message || data?.message || "이미지 제거에 실패했습니다.";
  921. return;
  922. }
  923. existingImagePath.value = null;
  924. existingImageName.value = null;
  925. successMessage.value = "이미지가 제거되었습니다.";
  926. } catch (e) {
  927. console.error("[ChallengeEdit] 이미지 제거 실패:", e);
  928. errorMessage.value = "서버 오류가 발생했습니다.";
  929. }
  930. }
  931. // ============================
  932. // 폼 제출
  933. // ============================
  934. async function handleSubmit() {
  935. errorMessage.value = "";
  936. successMessage.value = "";
  937. // 프론트 1차 검증
  938. if (!formData.value.name.trim()) return (errorMessage.value = "챌린지명을 입력하세요.");
  939. if (!formData.value.fee.toString().trim()) return (errorMessage.value = "참가비를 입력하세요.");
  940. if (!startDate.value) return (errorMessage.value = "시작일을 선택하세요.");
  941. if (!endDate.value) return (errorMessage.value = "종료일을 선택하세요.");
  942. if (!formData.value.max_participants) return (errorMessage.value = "최대 참가자를 입력하세요.");
  943. for (let i = 0; i < rounds.value.length; i++) {
  944. const r = rounds.value[i];
  945. if (!r.qualified) {
  946. return (errorMessage.value = `라운드 ${i + 1}의 진출자 수를 입력하세요.`);
  947. }
  948. if (r.place_mode === "specific") {
  949. if (r.places.length === 0) {
  950. return (errorMessage.value = `라운드 ${i + 1}에 장소를 1개 이상 추가하세요.`);
  951. }
  952. for (let j = 0; j < r.places.length; j++) {
  953. if (r.places[j].onboards.length === 0) {
  954. return (errorMessage.value = `라운드 ${i + 1} 장소 ${j + 1}에 선상을 1개 이상 선택하세요.`);
  955. }
  956. }
  957. }
  958. }
  959. isSaving.value = true;
  960. try {
  961. const payload = {
  962. name: formData.value.name,
  963. fee: formData.value.fee,
  964. start_date: startDate.value,
  965. end_date: endDate.value,
  966. max_participants: Number(formData.value.max_participants),
  967. description: formData.value.description,
  968. status_YN: formData.value.status_YN,
  969. rounds: rounds.value.map((r) => ({
  970. round_no: r.round_no,
  971. place_mode: r.place_mode,
  972. qualified: Number(r.qualified),
  973. items: r.place_mode === "all"
  974. ? r.items.map((it) => ({ item_id: it.item_id }))
  975. : [],
  976. places: r.place_mode === "specific"
  977. ? r.places.map((p) => ({
  978. // 'onboard-1', 'fishing-3' → [{type:'onboard', id:1}, {type:'fishing', id:3}, ...]
  979. onboards: p.onboards.map((key) => {
  980. const i = key.indexOf("-");
  981. return { type: key.substring(0, i), id: Number(key.substring(i + 1)) };
  982. }),
  983. items: p.items.map((it) => ({ item_id: it.item_id })),
  984. }))
  985. : [],
  986. })),
  987. };
  988. const { data, error } = await put(`/challenge/${challengeId}`, payload);
  989. if (error || !data?.success) {
  990. errorMessage.value = error?.message || data?.message || "수정에 실패했습니다.";
  991. return;
  992. }
  993. // 새 이미지가 선택되어 있으면 업로드 (기존 이미지 자동 교체)
  994. if (image.value) {
  995. const fd = new FormData();
  996. fd.append("image", image.value.file);
  997. const { data: imgRes, error: imgErr } = await upload(`/challenge/${challengeId}/image`, fd);
  998. if (imgErr || !imgRes?.success) {
  999. errorMessage.value = "챌린지는 수정됐지만 이미지 업로드에 실패했습니다.";
  1000. setTimeout(() => router.push(`/site-manager/challenge/detail/${challengeId}`), 1500);
  1001. return;
  1002. }
  1003. }
  1004. successMessage.value = data.message || "챌린지가 수정되었습니다.";
  1005. setTimeout(() => {
  1006. router.push(`/site-manager/challenge/detail/${challengeId}`);
  1007. }, 1000);
  1008. } catch (e) {
  1009. errorMessage.value = "서버 오류가 발생했습니다.";
  1010. console.error("Challenge save error:", e);
  1011. } finally {
  1012. isSaving.value = false;
  1013. }
  1014. }
  1015. const goToList = () => router.push("/site-manager/challenge/list");
  1016. const goToDetail = () => router.push(`/site-manager/challenge/detail/${challengeId}`);
  1017. onMounted(async () => {
  1018. document.addEventListener("click", handleDocumentClick);
  1019. // 옵션 먼저 로드한 후 챌린지 로드 (placesAll 채워야 onboards 매핑 가능)
  1020. await loadOptions();
  1021. await loadChallenge();
  1022. });
  1023. onBeforeUnmount(() => {
  1024. document.removeEventListener("click", handleDocumentClick);
  1025. });
  1026. </script>